Release: develop -> main#325
Open
github-actions[bot] wants to merge 284 commits into
Open
Conversation
… tests) (#357) ## Summary Stage 33 of the coverage push. Wire-format DTOs for the transaction-history flow that previously had no direct coverage. | File | Cases | | --- | --- | | \`transactions_dto.dart\` (TransactionType / State / Dto / helpers) | 14 | | \`account_history_dto.dart\` (TransferDto + HistoryEventDto + AccountHistoryDto) | 5 | ## What's pinned - **TransactionType.fromString:** resolves Buy/Sell/Swap/Referral; null in → null out; unknown returns null (no throw — the factory uses \`orElse: () => null\` so a new server-side type doesn't crash deserialisation). - **TransactionState:** canonical states; null + unknown → null; \`isPending\` is the inverse of \`{completed, failed, returned}\` — parameterised across every enum value to catch a new state silently being treated as terminal. - **TransactionDto:** full row parse; every field optional; integer numerics widen to double via the \`num\` cast. - **TransactionDto behaviour:** \`isPending\` mirrors \`state.isPending\` and is false when state is null; \`belongsToWallet\` matches sourceAccount case-insensitively, matches targetAccount when source is null, returns false when both are null. - **TransferDto / HistoryEventDto / AccountHistoryDto:** complete wire-shape parse; \`transfer\` optional; empty history list allowed. ## Test plan - [x] \`flutter analyze\` clean - [x] \`flutter test\` — 19 / 19 passing locally - [ ] CI green
## Summary Stage 31 of the coverage push. Wire-format DTOs for the KYC session hierarchy. | DTO | Cases | | --- | --- | | \`KycStepDto.fromJson\` | 4 | | \`KycSessionInfoDto\` + \`UrlType\` | 3 | | \`KycStepSessionDto.fromJson\` | 1 | | \`KycLevelDto.fromJson\` | 2 | | \`KycSessionDto.fromJson\` | 2 | ## What's pinned - **KycStepDto:** full-step parse (name + type + status + reason + sequenceNumber + isCurrent); type / reason are optional (null on the wire stays null); \`isCurrent\` defaults to \`false\` when **missing entirely** (\`??\` fallback — pinned because callers rely on this); unknown step name throws (no silent fallback). - **KycSessionInfoDto / UrlType:** url + UrlType parse; \`UrlTypeExtension.fromJson\` covers all four enum values (Browser/API/Token/None); unknown wire string throws \`ArgumentError\`. - **KycStepSessionDto:** inherits all step fields; carries a nested \`KycSessionInfoDto\` parsed from \`session\`. - **KycLevelDto:** level + step list; empty step list is allowed. - **KycSessionDto:** extends \`KycLevelDto\` with an **optional** \`currentStep\` (null stays null). ## Test plan - [x] \`flutter analyze\` clean - [x] \`flutter test\` — 12 / 12 passing locally - [ ] CI green
## Summary Stage 34 of the coverage push. Pure DTO tests for EIP-7702 wire shapes used in the RealUnit sell flow. ## Cases | Target | Cases | | --- | --- | | \`Eip7702Domain.fromJson\` | 1 — name + version + chainId + verifyingContract | | \`Eip7702TypeField.fromJson\` | 1 — name + type | | \`Eip7702Types.fromJson\` | 1 — \`Delegation\` + \`Caveat\` field lists | | \`Eip7702Message.fromJson\` | 1 — delegate / delegator / authority / caveats / salt | | \`Eip7702Data.fromJson\` | 1 — every top-level field + recursive nested DTO parse | | \`Eip7702AuthorizationDto.toJson\` | 2 — six-field round-trip; \`chainId\` / \`nonce\` accept both number and string (per source comment) | | \`Eip7702ConfirmDto.toJson\` | 1 — nested \`delegation\` + \`authorization\` | ## What's pinned - The nested \`fromJson\` chain (\`Eip7702Data\` → \`Eip7702Domain\` / \`Eip7702Types\` / \`Eip7702Message\`) actually walks all the way down. - \`Eip7702AuthorizationDto.chainId\` and \`nonce\` are typed \`dynamic\` on purpose so the wire-side number-or-string contract holds — covered explicitly. - \`Eip7702ConfirmDto.toJson\` preserves both nested JSON shapes byte-for-byte. ## Test plan - [x] \`flutter test test/packages/service/dfx/models/payment/eip7702_dtos_test.dart\` — all 8 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 35 of the coverage push. Pure DTO + enum tests for the RealUnit registration wire surface. ## Cases | Target | Cases | | --- | --- | | \`RegistrationEmailStatus.fromString\` | 3 — \`email_registered\` / \`merge_requested\` / throws on unknown | | \`RegistrationStatus.fromString\` | 4 — \`completed\` / \`pending_review\` / \`forwarding_failed\` / throws on unknown | | \`RegistrationUserType.fromName\` | 3 — \`HUMAN\` / \`CORPORATION\` / \`ArgumentError\` on unknown | | \`RealUnitEmailRegistrationRequestDto.toJson\` | 1 | | \`RealUnitRegisterWalletRequestDto.toJson\` | 1 | | \`RealUnitRegistrationEmailResponseDto.fromJson\` | 2 — happy path + propagated exception | | \`RealUnitRegistrationResponseDto.fromJson\` | 2 — happy path + propagated exception | | \`CountryAndTin\` | 1 — \`fromJson\` + \`toJson\` round-trip | | \`RealUnitRegistrationRequestDto.toJson\` | 2 — omits \`countryAndTINs\` when null; includes the list when provided | ## What's pinned - The wire strings stay locked: \`email_registered\` / \`merge_requested\` for email status, \`pending_review\` / \`forwarding_failed\` for registration status, \`HUMAN\` / \`CORPORATION\` for user type. - \`RealUnitRegistrationRequestDto.toJson\` omits the optional \`countryAndTINs\` key when null (not just \`null\`) and inlines the list of \`CountryAndTin.toJson()\` shapes when present. - Both response DTOs delegate to the enum parsers, so an unknown status surfaces an exception rather than a default — pinned by the negative case. ## Excluded (and why) - \`RegistrationUserType.name(BuildContext)\` — needs widget test infrastructure for \`S.of(context)\`, covered elsewhere via screen-level widget tests. ## Test plan - [x] \`flutter test test/packages/service/dfx/models/registration/registration_dtos_test.dart\` — 19 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 36 of the coverage push. Pure DTO tests for the RealUnit buy + sell wire surface, plus the \`PaymentInfoError\` enum contract. ## Cases | Target | Cases | | --- | --- | | \`RealUnitBuyDto.toJson\` | 2 — defaults currency to \`CHF\`; honours an override | | \`RealUnitBuyConfirmDto.fromJson\` | 1 — reference field | | \`RealUnitBuyPaymentInfoDto.fromJson\` | 2 — happy path; optional \`minVolume\` / \`maxVolume\` / \`paymentRequest\` / \`remittanceInfo\` as null | | \`RealUnitSellDto.toJson\` | 4 — amount-only; targetAmount-only; currency override; assertion that exactly one of \`amount\` / \`targetAmount\` is set | | \`BeneficiaryDto.fromJson\` | 2 — name + iban; name null | | \`PaymentInfoError\` | 1 — four documented variants pinned | ## What's pinned - \`RealUnitSellDto\` enforces XOR-style mutual exclusion on \`amount\` / \`targetAmount\` via assertion — both negative cases (neither / both) are covered. - The optional-field handling on \`RealUnitBuyPaymentInfoDto\` is byte-exact: \`minVolume\` / \`maxVolume\` / \`paymentRequest\` / \`remittanceInfo\` survive an explicit \`null\` on the wire. - \`Currency\` round-trips through \`.code\` on serialisation and \`Currency.fromCode\` on parse. - \`PaymentInfoError\` is locked at four variants so a sneaky addition doesn't slip past without an intentional bump. ## Excluded (and why) - \`RealUnitSellPaymentInfoDto.fromJson\` — entangles \`Eip7702Data\`, \`DfxFeesData\` and \`BeneficiaryDto\` parsing in one shot; better covered via the \`real_unit_sell_payment_info_service\` integration tests once PR #332 is in. ## Test plan - [x] \`flutter test test/packages/service/dfx/models/payment/buy_sell_dtos_test.dart\` — 12 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
… (+14 tests) (#361) ## Summary Stage 37 of the coverage push. Aggregate pure-DTO file for the remaining simple wire shapes across the DFX model surface. ## Cases | Target | Cases | | --- | --- | | \`BrokerbotBuyPriceDto.fromJson\` | 1 — pins \`totalPrice\` → \`totalCost\` rename | | \`BrokerbotBuySharesDto.fromJson\` | 1 | | \`BrokerbotSellPriceDto.fromJson\` | 1 | | \`BrokerbotSellSharesDto.fromJson\` | 1 | | \`FaucetResponseDto.fromJson\` | 1 | | \`DfxFeesData.fromJson\` | 1 — integer wire values widen to double | | \`Country\` | 2 — equality is by \`id\` only; \`DfxCountryDtoMapper.toCountry\` forwards all four fields | | \`UserDto.fromJson\` | 2 — \`mail\` + \`kyc\`; \`mail\` is optional | | \`RealUnitWalletStatusDto.fromJson\` | 2 — \`isRegistered\` + \`userData\`; unregistered / null \`userData\` branch | | \`RealUnitUserDataDto.fromJson\` | 2 — happy path; \`countryAndTINs\` list when provided | ## What's pinned - \`BrokerbotBuyPriceDto.fromJson\` maps \`totalPrice\` (wire) → \`totalCost\` (Dart) — the rename is locked. - \`Country.props\` is \`[id]\`, so two countries with the same id compare equal even if their other fields differ — covered explicitly. - The optional fields (\`mail\` on \`UserDto\`, \`userData\` on \`RealUnitWalletStatusDto\`, \`countryAndTINs\` on \`RealUnitUserDataDto\`) survive being null on the wire. - \`DfxFeesData\` widens \`int\` to \`double\` so an integer-typed JSON value doesn't throw. ## Test plan - [x] \`flutter test test/packages/service/dfx/models/aggregate_dtos_test.dart\` — 14 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
#362) ## Summary Stage 38 of the coverage push. Pure value-object + DTO file covering the PDF wire surface, the support issue ladder, BankAccount equatable contract and BuyPaymentInfo equality. ## Cases | Target | Cases | | --- | --- | | \`MultiReceiptDto.toJson\` | 2 — default CHF; renames \`txIds\` → \`txHashes\` | | \`SingleReceiptDto.toJson\` | 2 — default CHF; renames \`txId\` → \`txHash\` | | \`PdfDto.fromJson\` | 1 — reads \`pdfData\` | | \`BalancePdfDto.toJson\` | 2 — default EN (uppercased on wire); override + uppercased language code | | \`SupportIssueType\` | 3 — round-trip; falls back to \`genericIssue\`; \`toJson\` returns the wire value | | \`SupportIssueReason\` | 2 — round-trip; falls back to \`other\` | | \`SupportIssueState\` | 2 — round-trip; falls back to \`created\` | | \`SupportIssueDto.fromJson\` | 2 — full happy path; absent \`messages\` → \`[]\` | | \`SupportIssue\` | 2 — \`isOpen\` for \`created\` / \`pending\` only; \`fromDto\` maps fields | | \`BankAccount\` | 2 — equality by \`id\` only; \`isActive\` defaults to false | | \`BuyPaymentInfo\` | 1 — equatable props pin every field (currency-mismatch breaks equality) | ## What's pinned - The two-step \`txId\` rename (\`txId\` → \`txHash\`, \`txIds\` → \`txHashes\`) is locked on both \`SingleReceiptDto\` and \`MultiReceiptDto\`. - \`BalancePdfDto\` uppercases \`Language.code\` (\`en\` → \`EN\`) on the wire — a silent lowercase would break the contract. - All three support enums use \`firstWhere(... orElse: ...)\` to fall back to a sentinel on unknown wire values; each fallback is pinned by name so refactors can't silently change which sentinel returns. - \`SupportIssueDto.messages\` is optional on the wire and resolves to an empty list, not null. - \`BankAccount\` Equatable \`props\` is \`[id]\` only — two accounts with the same id compare equal even if name / iban / isActive differ. - \`BuyPaymentInfo\` props enumerate every field — the negative case (currency difference) is the canary. ## Test plan - [x] \`flutter test test/packages/service/dfx/models/pdf_support_bank_test.dart\` — 21 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 39 of the coverage push. Pure-DTO tests for the KYC financial-data wire surface plus the \`KycLevelDto\` wrapper that ties \`KycLevel\` to a list of \`KycStepDto\`. ## Cases | Target | Cases | | --- | --- | | \`QuestionType.fromValue\` | 2 — four documented wire values (\`Confirmation\` / \`SingleChoice\` / \`MultipleChoice\` / \`Text\`); throws \`ArgumentError\` on unknown | | \`KycFinancialOption.fromJson\` | 1 — key + text | | \`KycFinancialCondition.fromJson\` | 1 — question + response | | \`KycFinancialQuestion.fromJson\` | 2 — happy path (description + options + conditions); description / options / conditions all optional | | \`KycFinancialResponse\` | 1 — \`fromJson\` + \`toJson\` round-trip | | \`KycFinancialOutData.fromJson\` | 2 — with responses; absent \`responses\` defaults to \`[]\` | | \`KycLevelDto.fromJson\` | 1 — \`kycLevel\` + nested \`KycStepDto\` list | ## What's pinned - \`QuestionType.fromValue\` is **opt-in strict** (throws on unknown wire values) unlike the support enums which fall back to a sentinel — pinned by both the happy path and the negative case. - \`KycFinancialQuestion\` treats \`description\`, \`options\` and \`conditions\` as fully optional — wire-side \`null\` survives as Dart \`null\`, not an empty list. - \`KycFinancialOutData.responses\` defaults to \`[]\` when the key is absent (not \`null\`), distinguishing it from the question-side optionals. ## Test plan - [x] \`flutter test test/packages/service/dfx/models/kyc/kyc_financial_data_dto_test.dart\` — 10 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 42 of the coverage push. Pure-function tests for the two IBAN formatters that drive the bank-account input field. ## Cases | Target | Cases | | --- | --- | | \`IbanTextFormatter.formatIban\` | 5 — empty; lowercase → upper; strips existing spaces before regrouping; long input groups every four chars; exactly four chars: no trailing space | | \`IbanInputFormatter.formatEditUpdate\` | 4 — groups output in fours and uppercases; drops invalid characters silently; caret stays collapsed at the end; empty input remains empty | ## What's pinned - \`IbanTextFormatter\` is the read-side display helper — it does not validate characters, it just regroups and uppercases. - \`IbanInputFormatter\` is the write-side \`TextInputFormatter\`; the regex \`[A-Z0-9]\` is applied **after** uppercasing, so lowercase letters survive but punctuation does not. The "drops invalid characters silently" case pins this contract. - The caret behaviour (\`selection: TextSelection.collapsed(offset: text.length)\`) is essential UX — explicit case covers it so a future refactor can't silently strand the caret mid-string. ## Test plan - [x] \`flutter test test/widgets/iban_formatters_test.dart\` — 9 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 40 of the coverage push. First cubit-level coverage of the PIN authentication lifecycle: setup, verification, background lockout, and reset. ## Cases | Target | Cases | | --- | --- | | initial state | 1 — both flags false | | \`initialize\` | 2 — PIN set: \`isPinSetup=true\`, \`isPinVerified\` stays false; no PIN: \`isPinVerified\` auto-passes | | \`onPinSetupComplete\` | 1 — both flags true | | \`onPinVerified\` | 1 — preserves \`isPinSetup\`, flips \`isPinVerified\` | | background / resume lockout | 4 — resume without prior hide is a no-op; resume when PIN not setup is a no-op; elapsed < \`lockoutDuration\` keeps verification; \`lockoutDuration\` constant pin (5 min) | | \`reset\` | 1 — wipes pin hash + biometric + lockout, emits initial state | ## What's pinned - The "no PIN set" branch in \`initialize\` deliberately auto-passes verification — pinning it so a refactor doesn't silently force an unnecessary PIN prompt. - The lockout-window comparator and the \`lockoutDuration\` constant are tested in tandem: the elapsed-too-small branch via behavior, the boundary via the constant pin. The elapsed-too-large branch would require wall-clock manipulation; the constant pin is what catches drift today. - \`reset\` issues three independent storage deletes (\`deletePinHash\` + \`deleteBiometricEnabled\` + \`resetPinLockout\`) — all three are verified to fire once. ## Excluded (and why) - The elapsed-≥-\`lockoutDuration\` invalidation branch — requires fake-clock or 5 minutes of real wait; covered indirectly by the constant pin and by integration tests on the wider auth flow. ## Test plan - [x] \`flutter test test/screens/pin/pin_auth_cubit_test.dart\` — 10 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Four fixes that together let the BitBox-gated KYC registration run all the way through on iOS BLE without the user falling into a dead end: - **`fix(bitbox)`** Wrap `confirmPairing` and `createBitboxWallet` in 75s/30s timeouts so a silent BLE stall surfaces as `BitboxNotConnected` instead of an endless spinner. `ConnectBitboxCubit` gains injectable timeouts for unit tests. - **`fix(bitbox)`** Serialize all sign calls (`signToSignature`, `signPersonalMessage`, `signTypedDataV4`) through a static `_signQueue`. The bitbox02-api-go SDK keeps a single noise cipher per device, so two concurrent signs would advance the nonce out of order and break decryption permanently. - **`fix(bitbox)`** Transliterate every string field that goes into the EIP-712 envelope (and its matching DTO copy) to printable ASCII via `toBitboxSafeAscii` — covers German umlauts, French/Spanish/Portuguese accents, Polish/Czech letters, Nordic æ/ø/å, Romanian/Turkish. BitBox firmware rejects any non-ASCII byte in `string`-typed values with `ErrInvalidInput (101)`. KYC personal-data fields keep the original spelling so ID-verification still sees the legal name with diacritics. - **`fix(kyc)`** `KycCubit` now routes based on the status of the required steps, not just the numeric level. A high aggregate level with `ident=failed` or `financialData=outdated` no longer short-circuits to `KycCompleted` — the user is sent back through the unfinished steps via `_continueKyc`. ## Test plan - [x] `flutter test` (446 passing, +24 new across `connect_bitbox_cubit_test.dart`, `bitbox_credentials_test.dart`, `kyc_cubit_test.dart`, `eip712_signer_bitbox_test.dart`, `ascii_transliterate_test.dart`) - [x] `flutter analyze` clean - [x] Manual smoke on iPhone Air (iOS 26) — pair → 13-page EIP-712 sign with umlaut name → KYC continues to next required step --------- Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
## Summary Stage 41 of the coverage push. Cubit-level coverage of the 'show recovery seed' toggle screen. ## Cases | Target | Cases | | --- | --- | | initial state | 1 — seed mirrors \`wallet.seed\`; \`showSeed=false\` | | \`toggleShowSeed\` | 2 — single flip to true; double flip back to false | | \`SettingsSeedState\` | 2 — \`copyWith\` preserves untouched fields; Equatable props cover \`seed\` + \`showSeed\` | ## What's pinned - The toggle does not mutate the underlying seed — every emitted state still carries the canonical BIP39 test mnemonic. - \`copyWith\` is non-destructive: passing only \`showSeed\` keeps \`seed\` intact. - Equatable props enumerate both fields — pinned via the negative case (different \`showSeed\` breaks equality). ## Test plan - [x] \`flutter test test/screens/settings_seed/settings_seed_cubit_test.dart\` — 5 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
…367) ## Summary Stage 43 of the coverage push. Cubit-level coverage of the KYC financial-data step: question loading, conditional visibility, answer routing, navigation, submission and the \`LoadedSuccess\` view-model helpers. ## Cases | Target | Cases | | --- | --- | | initial state | 1 — \`KycFinancialDataInitial\` | | \`loadQuestions\` | 3 — Loading → LoadedSuccess; conditional questions filtered when their condition is unmet; Loading → Failure on service error | | \`answerQuestion\` | 2 — no-op outside LoadedSuccess; stores the answer and re-runs the visibility filter (conditional questions appear once their predicate matches) | | \`submitAndNext\` | 4 — non-last increments \`currentIndex\`; last emits Submitting → SubmitSuccess and calls \`setFinancialData\`; submit error surfaces a Failure; no-op outside LoadedSuccess | | \`goBack\` | 3 — decrements \`currentIndex\` from 1; no emit at index 0; no-op outside LoadedSuccess | | \`KycFinancialDataLoadedSuccess\` helpers | 4 — \`currentQuestion\` at index; \`isFirstQuestion\` / \`isLastQuestion\`; \`currentResponse\` + \`hasAnswer\` reflect the responses map; \`hasAnswer\` is false on an empty string response | ## What's pinned - Conditional-question visibility is **dynamic**: \`answerQuestion\` re-runs the filter so a previously hidden question becomes visible the moment its condition is satisfied. Pinned by a live-flip test on \`q3\` (visible only when \`q1 == 'a'\`). - \`submitAndNext\` only calls \`setFinancialData\` once the user is on the last visible question — the non-last branch must never reach the service. - \`goBack\` at index 0 must not emit (same state instance pinned with \`same\`), so the UI doesn't show a spurious rebuild when the user taps Back too hard. - \`hasAnswer\` treats an empty string as no answer — pins the contract used by the "Next" button's disabled state. ## Test plan - [x] \`flutter test test/screens/kyc/steps/financial_data/kyc_financial_data_cubit_test.dart\` — 17 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
) ## Summary Stage 44 of the coverage push. Pure-function tests for the seed-word string utilities and the BIP39 highlighting on the mnemonic input controller. ## Cases | Target | Cases | | --- | --- | | \`SeedStringExtension.seedWords\` | 5 — splits on single spaces; collapses repeated whitespace + tabs; strips leading/trailing whitespace; treats newlines as whitespace; empty input returns empty list | | \`MnemonicListExtension.seed\` | 2 — joins controller texts and trims each entry; preserves the controller order | | \`MnemonicInputFieldController.buildTextSpan\` | 2 — base style for a word in the BIP39 list; merges in the red non-match style for an unknown word | ## What's pinned - The seed-word split uses \`RegExp(r'\s+')\`, so any whitespace boundary (tab / newline / multiple spaces) counts — all four whitespace classes are exercised. - \`MnemonicListExtension.seed\` trims each controller entry individually before joining, so accidental leading/trailing spaces (e.g. from a paste) don't bleed into the recovered phrase. - \`buildTextSpan\` differentiates valid vs invalid BIP39 words via a style merge — a word that **is** in the wordlist returns the unmodified base style, anything else picks up the red status color. Pinned by both branches. ## Test plan - [x] \`flutter test test/widgets/mnemonic_extensions_test.dart\` — 9 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
…ts) (#383) ## Summary Stage 59 of the coverage push. Drives \`confirmPayment\` end-to-end with a real \`EthPrivateKey\` credential so the signing chain (\`Eip712Signer.signDelegation\` + \`Eip7702Signer.signAuthorization\`) is exercised, not just the validation guard. ## Cases | Case | What's pinned | | --- | --- | | Software wallet happy path | PUTs to \`/v1/realunit/sell/<id>/confirm\`; body carries the \`eip7702\` envelope (not the \`txHash\` branch); delegation block mirrors the eip7702 data + carries a 65-byte real signature; authorization block carries the matching chain / nonce / (r, s, yParity) | | 4xx response | \`ApiException\` propagates from \`_sendConfirm\` | ## What's pinned - The delegation \`salt\` is serialised as a **string** (\`'0'\`), not the raw int. Pinned via string equality so a regression to numeric serialization surfaces. - The authorization block uses \`chainId\` from \`domain.chainId\`, \`address\` from \`delegatorAddress\` (the MetaMask Delegator contract), and \`nonce\` from \`userNonce\` — the test asserts each value explicitly. - The signature length pin (132 chars including \`0x\`) catches a serialization regression that drops the trailing \`v\` byte. - Deterministic test key is shared with \`FakeBitboxCredentials\`. Reusing it keeps test infrastructure consistent and lets future BitBox-vs-software comparison tests reuse the same address. ## Test plan - [x] \`flutter test test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart\` — 2 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 45 of the coverage push. Cubit-level coverage of the KYC email-registration step. ## Cases | Target | Cases | | --- | --- | | initial state | 1 — \`KycEmailStepInitial\` | | \`registerEmail\` | 4 — Loading → Success on happy path; \`ApiException\` carrying 'does not match verified email' maps to \`emailDoesNotMatch\`; other \`ApiException\` → \`unknown\`; non-Api exception → \`unknown\` carrying \`e.toString()\` | | \`KycEmailStepFailure\` | 1 — Equatable props cover \`error\` + \`message\` | | \`KycEmailStepSuccess\` | 1 — Equatable props cover \`status\` | ## What's pinned - The substring match against the API error message (\`does not match verified email\`) is the only thing distinguishing \`emailDoesNotMatch\` from \`unknown\` — explicitly pinned by both branches. - Non-\`ApiException\` exceptions still surface as a failure (not an unhandled throw) and carry their \`toString()\` as the user-visible message. ## Test plan - [x] \`flutter test test/screens/kyc/steps/email/kyc_email_step_cubit_test.dart\` — 7 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 46 of the coverage push. Cubit-level coverage of the post-verification flow: detect account-id change, complete registration, settle on Success / Failure. ## Cases | Target | Cases | | --- | --- | | initial state | 1 — \`KycEmailVerificationInitial\` | | \`checkEmailVerification\` | 4 — same account id (token unchanged) → Failure; changed account id with existing user data → calls \`registerWallet\` then Success; changed id without userData → settles on Success (RegistrationFailure is intermediate, overwritten by outer emit); \`registerWallet\` throwing → cubit still settles on Success (no crash) | | \`getAccountId\` | 2 — null when no token; returns the \`account\` JWT claim when token is present | ## What's pinned - The verification check is **based on the JWT \`account\` claim changing**, not on a wire status field. Two JWTs with different \`account\` values flip the cubit; the same JWT twice surfaces Failure. - The cubit invalidates the auth token between the two reads — pinned via \`verify(() => auth.invalidateAuthToken()).called(1)\`. - The current behaviour is that a \`RegistrationFailure\` emit from \`_completeRegistration\` is **overwritten** by the outer \`emit(Success)\` once the helper returns. Two failure-path tests pin this so a future refactor that wants to surface the registration failure to the UI has to update the test deliberately. ## Test plan - [x] \`flutter test test/screens/kyc/steps/email/kyc_email_verification_cubit_test.dart\` — 7 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
…371) ## Summary Stage 47 of the coverage push. Cubit-level coverage of three related KYC step cubits. ## Cases | Target | Cases | | --- | --- | | \`KycNationalityCubit\` | 3 — initial state; \`setData\` fires with \`{nationality: {id: <id>}}\` + Loading → Success; failure path | | \`Kyc2FaCubit\` (request) | 3 — initial state; Loading → Success on \`request2FaCode\`; Loading → Failure on throw | | \`Kyc2FaVerifyCubit\` (verify) | 3 — initial state; Loading → Success on \`verify2FaCode\`; Loading → Failure on throw | ## What's pinned - The nationality body shape is \`{nationality: {id: <int>}}\` — pinned verbatim so a refactor doesn't quietly invert it (e.g. \`{nationalityId: <int>}\`). - Both 2FA cubits carry the raw \`e.toString()\` into the failure state's \`errorMessage\` — the UI uses that as fallback copy, so the contract is pinned. ## Test plan - [x] \`flutter test test/screens/kyc/steps/kyc_nationality_2fa_cubits_test.dart\` — 9 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 48 of the coverage push. Cubit-level coverage of the two-step KYC registration wizard (\`personal\` → \`address\`). ## Cases | Target | Cases | | --- | --- | | initial state | 1 — starts at personal; both steps in order; \`index=0\`; \`progress=0.5\`; \`canGoBack=false\` | | \`next\` | 2 — advances \`personal\` → \`address\` with \`progress=1.0\` and \`canGoBack=true\`; no-op at the last step | | \`previous\` | 2 — goes \`address\` → \`personal\`; no-op at the first step | | \`KycRegistrationStepState\` helpers | 2 — \`progress = (index + 1) / totalSteps\`; \`canGoBack\` is only false on \`personal\` | ## What's pinned - The progress formula uses \`index + 1\` so the bar fills evenly across the two steps (50% / 100%), not by-completion. Pinned via the closeTo check. - Bouncing off the bounds doesn't emit — both edge tests use \`same(before)\` so a future refactor that adds a redundant emit surfaces here. ## Test plan - [x] \`flutter test test/screens/kyc/steps/registration/kyc_registration_step_cubit_test.dart\` — 7 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 49 of the coverage push. Final big sell-flow DTO that wasn't reachable from the payment_dtos aggregate file — it recursively pulls in \`Eip7702Data\` + \`DfxFeesData\` + \`BeneficiaryDto\` + \`PriceStep\`. ## Cases | Target | Cases | | --- | --- | | \`RealUnitSellPaymentInfoDto.fromJson\` | 3 — full happy-path wire shape; nested \`Eip7702Data\` DTO walked all the way down (one leaf per level pinned); integer wire values for \`amount\` / \`exchangeRate\` / \`rate\` widen to double | ## What's pinned - The recursive \`fromJson\` chain actually parses every nested DTO. One leaf field per level (\`relayerAddress\` → \`domain.chainId\` → \`message.delegate\` → \`amountWei\`) is pinned so a regression to "stops at top level" surfaces here. - \`Currency.fromCode\` is the wire→Dart conversion; \`CHF\` round-trips correctly. - Integer-typed JSON values widen to double for the floating-point fields — pins the contract used by partner APIs that sometimes serialize whole-number rates as \`int\`. ## Test plan - [x] \`flutter test test/packages/service/dfx/models/payment/sell_payment_info_dto_test.dart\` — 3 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary
Stage 50 of the coverage push. Small widget test for the \`InfoRow\`
label/value row used across receipts and detail screens.
## Cases
| Target | Cases |
| --- | --- |
| \`InfoRow\` | 3 — renders the leading label with a \`:\` appended and
the trailing value verbatim; default padding is \`EdgeInsets.only(top:
5, bottom: 5)\`; custom padding overrides the default |
## What's pinned
- The trailing colon is a visual contract — pinned via
\`find.text('IBAN:')\` rather than \`find.textContaining('IBAN')\`.
- Default vs override padding is the only configurable behaviour — both
branches covered.
## Test plan
- [x] \`flutter test test/widgets/info_row_test.dart\` — 3 pass
- [x] \`flutter analyze\` clean on the new file
- [ ] CI green
## Summary Stage 51 of the coverage push. Widget tests for the round action button used on the dashboard (Buy / Sell / Receive). ## Cases | Target | Cases | | --- | --- | | \`ActionButton\` | 4 — renders icon + label when not loading (no activity indicator); renders the \`CupertinoActivityIndicator\` and hides icon + label when \`isLoading=true\`; tap fires \`onPressed\` when enabled; tap is disabled while loading (\`InkWell.onTap\` becomes \`null\`) | ## What's pinned - The loading state is **mutually exclusive** with the content: when \`isLoading=true\`, the icon and label are not present in the tree — pinned via \`findsNothing\`. - Tap-while-loading uses \`InkWell\`'s \`null\` onTap rather than just ignoring the callback, so the ink ripple never appears either. Counter-based assertion pins the callback contract. ## Test plan - [x] \`flutter test test/widgets/action_button_test.dart\` — 4 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 52 of the coverage push. Widget tests for the highlight-substring helper used by legal copy and toast snippets. ## Cases | Target | Cases | | --- | --- | | \`TextSubstringHighlighting\` | 6 — substring-not-found fallback (plain \`Text\`, internal \`TextSpan.children\` is null); substring-found splits into three spans (before / match / after); default \`highlightedStyle\` is \`base.copyWith(fontWeight: bold)\`; custom \`highlightedStyle\` replaces the default bold entirely; \`onHighlightedTap\` attaches a \`TapGestureRecognizer\` and firing it triggers the callback; null callback → recognizer is null | ## What's pinned - The fallback vs highlight branch is observable from the rendered tree: the fallback wraps a plain \`Text\` (internal \`RichText.text.children == null\`), the highlight branch builds a \`RichText\` directly with three children. Pin both. - The default highlighting style preserves the base font size (\`copyWith\`) — refactoring the default to a fresh \`TextStyle()\` would break callers that rely on font-size inheritance. - The tap recognizer is **optional**: when \`onHighlightedTap\` is null, no recognizer is attached so the span doesn't intercept touches. ## Test plan - [x] \`flutter test test/widgets/text_substring_highlighting_test.dart\` — 6 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 53 of the coverage push. Widget tests for the privacy-mode amount renderer used across balance + transaction screens. ## Cases | Branch | Cases | | --- | --- | | \`state.hideAmounts=false\` | 4 — zero amount renders placeholder \`'€ --.-- '\`; non-zero renders via \`formatFixed\`; empty \`leadingSymbol\` drops the \`'€ '\` prefix; \`trailingSymbol\` is appended after the amount | | \`state.hideAmounts=true\` | 2 — renders \`'€ ***.**'\` regardless of the amount; empty \`leadingSymbol\` still hides the amount with \`'***.**'\` | ## What's pinned - The zero-amount placeholder is exactly \`'--.-- '\` (with a trailing space from the empty default \`trailingSymbol\`). - Privacy mode (\`hideAmounts=true\`) is a hard override — non-zero amounts and \`trailingSymbol\` are not displayed. - The leading-symbol contract is \`'<symbol> '\` (symbol + single space) when non-empty, and is completely dropped when empty — pinned by both the visible and the absent variant. ## Test plan - [x] \`flutter test test/widgets/hide_amount_text_test.dart\` — 6 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 54 of the coverage push. First cubit-level coverage of the BitBox sell flow now that PR #332 has landed (BitBox sign hardening + \`FakeBitboxCredentials\`). ## Cases | Target | Cases | | --- | --- | | constructor / \`_checkEthBalance\` | 4 — disconnected BitBox → \`SellBitboxBitboxRequired\`; \`ethBalance >= requiredGasEth\` → \`SellBitboxEthReady\`; \`ethBalance < required\` + faucet success → \`WaitingForEth\` (\`faucet.requestFaucet\` called once); faucet throws → \`SellBitboxError\` | | \`proceedToSwap\` | 2 — success: \`Preparing\` → \`AwaitingSwapConfirm\` with both raw txs; failure: \`Error\` | | \`confirmSwap\` | 2 — no-op outside \`AwaitingSwapConfirm\`; non-Bitbox credentials in \`AwaitingSwapConfirm\` → \`Error\` ("BitBox wallet not connected") | | \`confirmDeposit\` | 1 — no-op outside \`AwaitingDepositConfirm\` | | \`retryDeposit\` | 1 — no-op outside \`DepositRetry\` | ## What's pinned - The constructor schedules \`_checkEthBalance\` via \`scheduleMicrotask\`, so all initial-state assertions are made after \`stream.firstWhere((s) => s is! SellBitboxCheckingEth)\`. The fleeting intermediate states (\`CheckingEth\`, \`RequestingFaucet\`) are not asserted but the terminal state is pinned. - The BitBox-required branch reuses \`FakeBitboxCredentials\` from PR #332 with \`bitboxManager = null\` to flip \`isConnected\` to false — same fake that production sign code special-cases. - The non-Bitbox credentials branch in \`confirmSwap\` is the safety net that protects users on a software wallet from accidentally entering the BitBox sign ceremony. - Sign / broadcast / retry happy paths require a real \`MsgSignature\` round-trip — covered separately by the BitBox signer tests in #332. This file pins the cubit's state machine wiring, not the crypto. ## Excluded (and why) - Sign-and-broadcast happy paths (\`confirmDeposit\` success → \`SellBitboxSuccess\`, \`retryDeposit\` success, broadcast-on-deposit failure → \`DepositRetry\`) — these need an actual BitBox sign result threaded through the AppStore wallet chain and a working \`sellService.broadcastTransaction\` mock; deferred to a follow-up stage if needed. ## Test plan - [x] \`flutter test test/screens/sell_bitbox/sell_bitbox_cubit_test.dart\` — 10 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
) ## Summary Stage 55 of the coverage push. First service-level coverage of the registration HTTP service now that PR #332's BitBox sign hardening + \`FakeBitboxCredentials\` are in develop. ## Cases | Target | Cases | | --- | --- | | \`registerEmail\` | 3 — happy path lowercases the email + carries the bearer token; 202 Accepted treated as success; 4xx throws \`ApiException\` | | \`completeRegistration\` | 1 — disconnected BitBox throws \`BitboxNotConnectedException\` before the signing ceremony runs | | \`registerWallet\` | 1 — disconnected BitBox throws \`BitboxNotConnectedException\` before the signing ceremony runs | ## What's pinned - Email is **lowercased** before going on the wire — pinned because the backend treats e-mails case-insensitively and a refactor that drops \`.toLowerCase()\` would create duplicate-account ghosts. - Both \`completeRegistration\` and \`registerWallet\` short-circuit with a typed \`BitboxNotConnectedException\` BEFORE they touch the signer. This is the contract the UI relies on to surface a "connect your BitBox" prompt without a wasted sign attempt. - Bearer-token plumbing comes from \`sessionCache.authToken\` — pinned via header assertion in the happy path. ## Excluded (and why) - Sign-and-post happy paths for \`completeRegistration\` / \`registerWallet\` — would need an end-to-end EIP-712 roundtrip with \`FakeBitboxCredentials(success)\` + a stub server that validates the recovered signer. Deferred to a follow-up stage; the BitBox sign code itself is covered by PR #332's \`eip712_signer_bitbox_test.dart\`. ## Test plan - [x] \`flutter test test/packages/service/dfx/real_unit_registration_service_test.dart\` — 5 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Summary Stage 56 of the coverage push. HTTP-surface coverage of the sell payment-info service now that PR #332 has landed. Skips sign-heavy \`confirmPayment\` in favour of the routes the BitBox flow uses. ## Cases | Target | Cases | | --- | --- | | \`getPaymentInfo\` | 3 — 200 → parsed \`SellPaymentInfo\` + body carries \`amount\` + \`iban\` + \`currency\` code; 403 → \`ApiException\`; 500 → \`ApiException\` | | \`createUnsignedTransactions\` | 2 — 200 → parsed swap + deposit; path is \`/v1/realunit/sell/<id>/unsigned-transactions\`; 500 → \`ApiException\` | | \`broadcastTransaction\` | 2 — 201 → returns \`txHash\`; path is \`/v1/realunit/sell/<id>/broadcast\`; 500 → \`ApiException\` | | \`confirmPaymentWithTxHash\` | 1 — PUTs \`/confirm\` with ONLY the \`txHash\` payload (no \`eip7702\` envelope) | ## What's pinned - All three id-based endpoints embed the id in the URL — pinned via path assertions so a refactor to a query-param shape surfaces here. - \`getPaymentInfo\` carries \`amount\` + \`iban\` + \`Currency.code\` on the wire — the BitBox SellBitboxCubit relies on this contract. - \`confirmPaymentWithTxHash\` is the BitBox-flow shortcut: it sends ONLY the txHash branch of \`RealUnitSellConfirmDto\`, no EIP-7702 envelope. Pinned via \`body.containsKey('eip7702')\` negative. ## Excluded (and why) - \`confirmPayment\` (EIP-712 + EIP-7702 sign roundtrip) — would need a full \`FakeBitboxCredentials(success)\` sign flow with both \`signDelegation\` and \`signAuthorization\`. Deferred; signer correctness itself is covered by the EIP-7702 signer tests in develop. ## Test plan - [x] \`flutter test test/packages/service/dfx/real_unit_sell_payment_info_service_test.dart\` — 8 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
…ests) (#381) ## Summary Stage 57 of the coverage push. Drives the \`SellBitboxCubit\` through the real \`FakeBitboxCredentials(success)\` sign ceremony to cover the post-#332 \`confirmSwap\` / \`confirmDeposit\` / \`retryDeposit\` happy paths that the cubit-test in PR #378 had deferred. ## Cases | Target | Cases | | --- | --- | | \`confirmSwap\` | 2 — signs swap tx → \`AwaitingDepositConfirm\` carries the signed envelope with the raw tx byte-for-byte + 32-byte r/s pair, \`signCallCount=1\`; strips optional \`0x\` prefix before hex-decoding the raw tx | | \`confirmDeposit\` | 2 — signs deposit, broadcasts swap then deposit, calls \`confirmPaymentWithTxHash\` with the deposit txHash, \`signCallCount=2\`; deposit-broadcast failure → \`SellBitboxDepositRetry\` carrying both signed envelopes + the error message | | \`retryDeposit\` | 2 — successful retry emits \`SellBitboxSuccess\` (broadcast count reaches 3); a retry that throws again stays in \`DepositRetry\` with the new \`errorMessage\` | ## What's pinned - The cubit hex-decodes the raw transaction whether it starts with \`0x\` or not — pinned via the mixed-prefix test so a regression to a strict prefix check surfaces here. - \`confirmDeposit\` issues TWO broadcasts (the already-signed swap + the freshly-signed deposit) before calling \`confirmPaymentWithTxHash\`. The first broadcast's failure is **not** retried — pinned by the call ordering. - \`DepositRetry\` carries both signed envelopes verbatim so \`retryDeposit\` can re-broadcast without re-signing — pinned by checking that \`signCallCount\` stays at 2 across the retry. - The signed (r, s) pair always comes back as a 0x-prefixed 32-byte hex string (66 chars including the prefix) — pinned via length assertion, which catches an off-by-one padding regression. ## Test plan - [x] \`flutter test test/screens/sell_bitbox/sell_bitbox_cubit_happy_paths_test.dart\` — 6 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
…py paths (+2 tests) (#384) ## Summary Stage 60 of the coverage push. Drives the two BitBox-aware registration endpoints with a real \`EthPrivateKey\` credential so the EIP-712 signing chain + ASCII transliteration are exercised end-to-end (Stage 55 only covered the BitBox-disconnected guard). ## Cases | Endpoint | What's pinned | | --- | --- | | \`completeRegistration\` | POST to \`/v1/realunit/register/complete\`; envelope is ASCII-transliterated (\`Adä → Adae\`, \`Zürich → Zuerich\`, email lowercased); 65-byte EIP-712 signature; \`kycData\` keeps the ORIGINAL diacritics (\`Adä\`, \`Loveläce\`, \`Zürich\`, \`Bahnhofstraße\`) so ID-verification sees the legal name | | \`registerWallet\` | POST to \`/v1/realunit/register/wallet\` with \`walletAddress\` in EIP-55 form, a 65-byte signature, and a YYYY-MM-DD \`registrationDate\` | ## What's pinned - The **split treatment** of personal data is the load-bearing invariant: the SIGNED envelope copy is ASCII-transliterated (because BitBox firmware rejects non-ASCII), but \`kycData\` keeps the original diacritics so the ID-verification provider sees the legal name. Pinned by asserting both halves of the body. - Email gets \`toLowerCase()\` + \`toBitboxSafeAscii()\` — \`AdA@ExAmPlE.COM\` → \`ada@example.com\`. - Signature length pin (132 chars including \`0x\`) catches a regression that drops the trailing \`v\` byte. ## Test plan - [x] \`flutter test test/packages/service/dfx/real_unit_registration_service_happy_test.dart\` — 2 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
…s) (#382) ## Summary Stage 58 of the coverage push. Drives every negative branch of \`_validateEip7702Data\` via \`confirmPayment\` so a regression in the guard surfaces here instead of in a real signing flow. ## Cases | Branch | Trigger | | --- | --- | | MetaMask Delegator mismatch | wrong \`delegatorAddress\` | | Delegation manager mismatch | wrong \`delegationManagerAddress\` | | Verifying contract mismatch | wrong \`domain.verifyingContract\` | | Wallet mismatch | \`message.delegator != walletAddress\` | | Chain ID mismatch | \`domain.chainId != expectedChainId\` | | Relayer mismatch | \`message.delegate != relayerAddress\` | | Token mismatch | \`tokenAddress != RealUnit asset address\` | | Amount mismatch | \`amountWei != userAmount * 10^decimals\` | | Amount parse | \`amountWei\` is non-numeric | ## What's pinned - MetaMask Delegation Framework v1.3.0 constants (\`_metaMaskDelegator\` + \`_delegationManager\`) are reproduced verbatim in the test file so a refactor that updates one set of constants without the other surfaces here. - The check on \`tokenAddress\` ties the EIP-7702 envelope to \`apiConfig.asset.address\` — the test uses the live mainnet RealUnit asset address (\`0x553C...090B\`) to keep the production constant in scope. - \`amountWei\` is computed as \`userAmount * 10^decimals\`. \`realUnitAsset.decimals == 0\` so the expected wei equals the user amount — the test passes \`userAmount=100\` and \`amountWei='99'\` to trigger the mismatch. - Non-numeric \`amountWei\` falls through the same \`amount mismatch\` branch, not a parser exception. Pinned. ## Test plan - [x] \`flutter test test/packages/service/dfx/real_unit_sell_payment_info_service_validation_test.dart\` — 9 pass - [x] \`flutter analyze\` clean on the new file - [ ] CI green
## Automatic Staging PR This PR was automatically created after changes were pushed to staging. **Commits:** 1 new commit(s) ### Checklist - [ ] Review all changes - [ ] Verify CI passes - [ ] Approve and merge to promote into develop --------- Co-authored-by: Josh <joshua.krueger@dfx.swiss> Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
feat(handbook): add task-overview section rendered from mounted CSV (#662)
Promote: staging -> develop
## Summary Drop-in replacement for `assets/images/splash/splash_background.png` with the brand designer's updated asset. - **Mountain key visual kept** (per design alignment) — only the subline changed. - Subline unified to **"Sicher. Einfach. Bankenunabhängig."** (was "Bankenunabhängig. Sicher."). - 5120×5120, sRGB — same format/dimensions as the previous asset (clean drop-in). This asset doubles as the OS launch splash and the in-app HomePage background; layout constraints (logo + subline in the upper ~60 %, footer in the gradient-covered lower area) are respected. ## Test plan - [ ] Visual Regression: the Welcome/HomePage golden changes (new subline). `golden-regenerate` triggered on this branch; VR green after regenerated goldens are committed. - [ ] Manual: launch splash + Welcome screen show the new subline; mountain unchanged. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Automatic Staging PR This PR was automatically created after changes were pushed to staging. **Commits:** 1 new commit(s) ### Checklist - [ ] Review all changes - [ ] Verify CI passes - [ ] Approve and merge to promote into develop Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
fix(splash): regenerate native launch assets (OS launch splash showed old image)
## Automatic Staging PR This PR was automatically created after changes were pushed to staging. **Commits:** 1 new commit(s) ### Checklist - [ ] Review all changes - [ ] Verify CI passes - [ ] Approve and merge to promote into develop Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
docs(handbook): Download / Test access section (#spec-downloads)
## Problem
Der Handbook-Deploy triggerte bisher **nur auf `develop`** und fächerte
diesen einen Run in **beide** Environments auf — beide Jobs pushten
denselben Docker-Tag (`:beta`). Das koppelt DEV und PRD und führt zum
**Clobber**: ein develop-Build überschreibt das dev-Image (und
umgekehrt) auf Docker Hub, sobald sich Builds zeitlich überlappen.
## Lösung: ein Environment pro Branch
| Push auf | baut aus | deployt | Tag | Endpoint |
| --- | --- | --- | --- | --- |
| `staging` | staging | **DEV** | `:beta` | dev-handbook.realunit.app |
| `develop` | develop | **PRD** | `:latest` | handbook.realunit.app |
## Änderungen
**`.github/workflows/handbook-deploy.yaml`**
- Trigger auf `[staging, develop]` (+ `workflow_dispatch`, Ziel-Env nach
dispatchtem Branch)
- Routing je Branch über `if: github.ref_name == 'staging' | 'develop'`
- **In-Run-Kopplung (`needs:`/sequenziell) entfernt** — die „DEV grün
vor PRD"-Garantie liefert jetzt der Promotion-Flow selbst: Inhalt
erreicht `develop` erst nach `staging` (via `auto-staging-pr.yaml`),
also nach DEV-Build+Smoke
- **Concurrency-Gruppe pro Branch** (`handbook-deploy-${{
github.ref_name }}`) → DEV und PRD blockieren/canceln sich nicht mehr
gegenseitig
- **Getrennte Image-Tags** (`:beta` DEV, `:latest` PRD) → kein Clobber
mehr
**`.github/workflows/handbook.yaml`**
- Neuer required Input **`ref`**; der Handbook-Checkout nutzt ihn → DEV
baut den **staging-Stand**, PRD den **develop-Stand** (jeweils der
triggernde Branch)
- Der `DFXswiss/api`-Mail-Preview-Checkout bleibt für **beide** Envs auf
`develop` gepinnt (er folgt dem Handbook-Branch-Split bewusst **nicht**)
— klargestellt im Code-Kommentar
**Doku:** README-Workflow-Tabelle, `docs/handbook/README.md`
Trigger-Sektion und zwei Prosa-Stellen in `docs/handbook/de/index.html`
auf das neue Mapping aktualisiert.
## Tag-Wahl / Server-Kompatibilität
Die Tags `:beta` (DEV) / `:latest` (PRD) entsprechen exakt dem **bereits
in `README.md` dokumentierten** Mapping (der YAML war auf beide-`:beta`
regressiert). Damit bleiben die serverseitigen Compose-Referenzen auf
dfxdev/dfxprd gültig — **keine Server-Änderung nötig**.
## Lokal verifiziert
- ✅ `actionlint` sauber auf beiden geänderten Workflows
(Reusable-Input-Verdrahtung inkl. neuem `ref` valide). Die einzigen
Hinweise sind **vorbestehende** `SC2012`-infos in der **nicht
angefassten** Mail-Preview-Stufe.
- ✅ Beide Handbook-Generatoren sind No-ops auf der editierten
`index.html` (Sync-Gates grün)
- ✅ `docker build -f Dockerfile.handbook` + Container-Smoke (`/healthz`
200, `/de/` 401) grün; geänderte Prosa im Image vorhanden
- ✅ YAML-Parse OK
Promote: staging -> develop
…rm (#696) ## Problem The **Wallet-Adresse** screen rendered the receive address in **lowercase** (`0x127d4a7e…ae8aa2`) in both the QR code and the address text. `SettingsWalletAddressPage` used `AppStore.primaryAddress`, which returns the lowercase `.hex` form. The canonical, verifiable representation of an Ethereum address is its **EIP-55 checksummed** form (mixed case). Showing lowercase is incorrect for a receive address users are meant to verify. ## Fix - In `settings_wallet_address_page.dart`, convert the address to its checksummed form via `EthereumAddress.fromHex(...).hexEip55` before passing it to the QR code and the displayed text. - **Scoped to this screen only** — `AppStore.primaryAddress` and all other consumers are untouched (per request: wallet-address screen fix only). ## Tests - Added a page test asserting the rendered `QRAddressWidget` receives the **checksummed** address in both its `subtitle` (text) and `uri` (QR), given a lowercase mock. - `flutter analyze` clean; page + QR-widget tests green. - Golden `settings_wallet_address_page_default` regenerated via `golden-regenerate.yaml` (address text now mixed-case). ## Test plan - [ ] Open Wallet-Adresse screen → address shown checksummed, QR encodes checksummed, copy copies checksummed. - [ ] Visual Regression green against the regenerated baseline. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
… price chart (#695) ## Problem The public price view ("RealUnit Aktienkurs") shows an **empty chart** and `CHF --.--` whenever the RealUnit quote is temporarily unavailable. Confirmed against the live API: - `GET /v1/realunit/price` → `{"timestamp":"2026-06-04T22:28:16.539Z"}` — **no `chf`/`eur`**. - `GET /v1/realunit/price/history?timeFrame=ALL` → **1514 points, 1513 valid**, only the latest (2026-06-04) has no `chf`/`eur`. `DFXPriceService.getPriceChart` did `BigInt.from(entry['chf'] * 100)` for every entry, so the single null point threw (`null * 100`), the whole method threw, and `priceChart` stayed `[]` → the entire history (1513 valid points) was discarded and the chart rendered empty. `getPriceOfAsset` threw on the same null. This is the same defect class as the portfolio-chart fix in #694, but in the **price** code path (`dfx_price_service.dart`), which #694 does not touch. It is more visible because it affects the wallet-less public price view. ## Fix - `getPriceChart`: skip entries whose selected-currency price is `null` instead of throwing — the chart renders the full history minus the unpriced tail point. - `getPriceOfAsset`: return `BigInt.zero` when the price is missing, so the UI renders `--.--` instead of throwing. - `getChfToEurRate`: treat a missing `chf`/`eur` as `0` (already guards `chf > 0`). ## Tests - `getPriceChart` skips a trailing null point and keeps earlier valued points. - `getPriceOfAsset` returns zero on a missing price. - `getChfToEurRate` returns `0.0` on a missing price. - `flutter analyze` clean; price-service + price-chart-cubit suites green (22 tests). ## Test plan - [ ] Open the app with no wallet while the quote endpoint omits `chf`/`eur` → price chart renders full history, header `--.--`, no empty chart. - [ ] Normal case (all prices present) → unchanged. - [ ] Current price endpoint without `chf`/`eur` → header `--.--`, no crash. ## Related - Sibling fix for the portfolio chart: #694 (same null-handling defect, account endpoint).
#694) ## Problem When the REALU quote is temporarily unavailable, the dashboard showed a corrupt portfolio chart: the line crashed to **0** at the most recent point and the performance box reported **-921.27 CHF | -100.00%**, even though the header correctly rendered the total as `--.--`. Root cause: the API returns `valueChf` / `valueEur` as `null` for points it cannot price. `RealUnitAccountService.getPortfolioHistory` mapped that `null` to `0` (`value ?? 0`). The latest history point (balance still held, price `null`) was therefore plotted as a zero-value point, dragging the chart to the x-axis and making the `last - first` performance calc read `-100%`. ## Fix - Skip history points whose value is `null` in the selected currency instead of mapping them to `0`. The chart now ends at the last **known** value and the change is computed against it; the header keeps showing `--.--` (already handled). - A genuine `0.0` value (balance held but worth zero) is preserved and stays distinct from `null` (unknown). Single change at the API boundary — no chart/cubit/widget changes needed, since the false `0` originated in the service mapping. ## Tests - Rewrote the obsolete `treats a null value as 0` test → `skips a point whose value is null` (point is dropped). - Added `keeps a genuine 0.0 value (distinct from null)`. - Added `drops a trailing null point but keeps earlier valued points`. - `flutter analyze` clean; dashboard + dfx service suites green (516 tests). ## Test plan - [ ] Wallet with holdings while the quote endpoint returns `null` for the latest point → chart stays at last known value, header `--.--`, no `-100%`. - [ ] Normal case with all values present → unchanged. - [ ] Holding genuinely worth 0 → still renders 0, not dropped.
Promote: staging -> develop
fix(android): track Fastlane Gemfile.lock to fix android-deploy
…ge (#699) ## Problem When buying/selling RealUnit while the external price provider (Aktionariat) is down, the app showed a generic "An error occurred … contact support" message. That wrongly implies the problem is with RealUnit or the app, when it is the external price provider. ## Change Pairs with API PR DFXswiss/api#3825, which now returns `503` / `code: PRICE_SOURCE_UNAVAILABLE` for the buy/sell quote during a price-source outage. - New `PaymentInfoError.priceSourceUnavailable`. - Buy (`buy_payment_info_cubit.dart`) and sell (`sell_payment_info_cubit.dart`): add an `on ApiException` clause — matched on `statusCode == 503` **or** `code == 'PRICE_SOURCE_UNAVAILABLE'` — kept **below** the KYC/Registration clauses (those are `ApiException` subclasses). Matching on both makes it defensive even before the API ships. - Render an explicit message attributing the problem to Aktionariat: buy shows it inline (`payment_information.dart`), sell shows it as a snackbar (`sell_button.dart`). - New i18n keys `priceProviderUnavailableTitle` / `priceProviderUnavailableDescription` (de + en). Dashboard intentionally unchanged (still `--.--`) — scope is buy/sell only. ## Copy (de) > **Problem beim Kursanbieter (Aktionariat)** > Der externe Kursanbieter Aktionariat liefert aktuell keine Kurse. Das Problem liegt bei Aktionariat – nicht bei RealUnit oder der App. Sobald Aktionariat wieder Kurse liefert, funktioniert alles automatisch. Bitte später erneut versuchen. ## Tests - Buy + sell payment-info cubit tests: `503` and `code` → `priceSourceUnavailable`; other `ApiException` → `unknown`. - New buy golden `buy_price_source_unavailable` (regenerated via `golden-regenerate.yaml`). - `flutter analyze` clean; buy/sell cubit suites green. ## Test plan - [ ] Buy/sell while the price source is down (current real state) → explicit Aktionariat message, not the generic one. - [ ] Normal case (price available) → unchanged. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Automatic Staging PR This PR was automatically created after changes were pushed to staging. **Commits:** 1 new commit(s) ### Checklist - [ ] Review all changes - [ ] Verify CI passes - [ ] Approve and merge to promote into develop --------- Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…) (#703) Reverts [#661](#661) (issue [#660](#660)). ## The bug [#661](#661) relabelled the buy-payment button `buyPaymentConfirm` from *"Click here once you have made the transfer"* to *"Request payment instructions by email"* / *"Zahlungsanweisungen per E-Mail anfordern"*, on the premise that the click *requests* the payment slip by email and the customer pays afterwards. That premise is wrong. On the screen where this button lives (`PaymentInformationDetails`): | Element | Content | |---|---| | `buyPaymentInformationDescription` (above) | "Please transfer the purchase amount using your banking app with these details. The purpose of payment is important!" | | On screen | IBAN, BIC, name, address **and the QR payment request** — the instructions are **already shown** | | Button (after #661) | "Request payment instructions by email" | | `PaymentExecutedSheet` (after tap) | "Thank you. As soon as your transfer has been received, we will transfer the REALU tokens…" | The button's `onPressed` calls `confirmPayment()` → `PUT /v1/realunit/buy/{id}/confirm`, which **confirms the order** and returns a reference. It does not email any instructions, and the instructions are not delivered by email — they are rendered on the same screen. The new label therefore contradicts both the description above it and the success sheet below it, and mislabels a confirm action as an info request. ## The fix Full revert of [#661](#661). Restores the original label, which is consistent with the surrounding copy and the actual `/confirm` behaviour: | Lang | Restored value | |---|---| | de | `Klicken Sie hier, sobald Sie die Überweisung getätigt haben` | | en | `Click here once you have made the transfer` | ## Scope - `buyPaymentConfirm` value in `strings_de.arb` + `strings_en.arb` (key position unchanged, no other keys touched). - Reverts the `home_page_loaded.png` golden that [#661](#661) had bundled in. Goldens are regenerated authoritatively on the dfx01 runner via `golden-regenerate.yaml` after push (the `buy_payment_info_loaded` golden must pick up the restored label). - `lib/generated/i18n.dart` is git-ignored and regenerated in CI. Draft until regenerated goldens land and CI (Analyze & Test + Visual Regression + Coverage Floor) is green. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
## Summary Backend [DFXswiss/api#3836](DFXswiss/api#3836) drops `KycRequired` from `RealUnitRegistrationState` (YAGNI — it is no longer emitted by `GET /v1/realunit/registration`). This removes the now-unreachable consumer-side handling so the app stops carrying dead code for a state the API never sends. ## Changes - `real_unit_registration_state.dart` — remove the `kycRequired(jsonName: 'KycRequired')` enum value. - `kyc_cubit.dart` — remove the `case RealUnitRegistrationState.kycRequired` dispatch arm. - `kyc_state.dart` — remove the `KycRequiredFailure` state class. - `kyc_page_manager.dart` — remove the `KycRequiredFailure()` → `KycFailurePage` switch arm. - `real_unit_registration_info_dto.dart` — drop the stale `kycRequired` mention from the doc comment. - `strings_de.arb` / `strings_en.arb` — remove the unused `kycRequiredFailureMessage` key. - `docs/wallet-modes.md` — drop the registration `KycRequired` references. - Tests — remove the obsolete `KycRequired` parse test and the `KycRequiredFailure` cubit test. ## Note `PaymentInfoError.kycRequired` (buy/sell) and the transaction-status `kycRequired` are unrelated concepts and remain untouched. After this change, an unexpected `state: 'KycRequired'` payload would throw `ArgumentError` (covered by the existing unknown-state test) — acceptable since the API no longer emits it. ## Test plan - [x] `flutter analyze` — clean (only a pre-existing, unrelated generated-i18n warning present on `staging`) - [x] `flutter test` — 2272 non-golden tests pass - [ ] Visual Regression / goldens via CI (no golden references the removed state)
Promote: staging -> develop
fix(android): publish releases to Open testing track (not internal)
Promote: staging -> develop
## Problem
A BitBox (hardware) wallet could be persisted with an **empty on-chain
address**. On the next app launch — after PIN entry — the dashboard
build reads that address through `EthereumAddress.fromHex("")`, which
throws. In release this is uncaught in the build phase and surfaces as a
**bare grey screen** (the default `ErrorWidget`). Software wallets are
unaffected because they always derive a real address.
## Root cause
`bitbox_flutter`'s `getETHAddress` coerces a native `null` into `""` at
the transport boundary (`bitbox_usb_method_channel.dart` → `return
result ?? '';`; the iOS/Android handlers return it unvalidated). When
the device isn't fully ready (e.g. a transient BLE stall right after
channel-hash verify), the address comes back empty, and
`createBitboxWallet` persisted it with **no validation** — this gap has
existed since BitBox support was first added. The pairing ceremony also
fetched the address with no device-ready re-check or retry (unlike the
existing channel-hash retry loop).
## What changed
- **Central retrying boundary** — `BitboxService.getEthAddress` never
returns empty: a transient empty read self-recovers across bounded
retries; a persistent one throws the new typed
`BitboxAddressUnavailableException`.
- **Validate before persist** — `createBitboxWallet` (and the heal path)
route through that boundary and keep a format guard, so an empty/invalid
address can never land on disk again. A failed fetch falls back to the
pairing flow's existing retry path.
- **Self-heal for already-corrupted wallets** — at load, a BitBox row
with an empty/invalid address is detected (non-throwing) and the app
diverts to a re-pairing recovery page that re-derives and backfills the
address onto the existing row, then continues to the dashboard. This is
local key derivation (no API state). Cancelling removes the unusable
view-wallet (keys live on the device; re-pairing re-derives the same
address) so the user is never stranded.
- **Defense-in-depth** — a custom `ErrorWidget.builder` replaces the
silent grey box with a logged, on-brand surface, and routes uncaught
build errors through `FlutterError.onError`.
## Test plan
- [x] `flutter analyze` clean
- [x] `flutter test --exclude-tags golden` — full suite green (2334
tests)
- [x] Unit: `createBitboxWallet` rejects empty/invalid without
persisting; `getEthAddress` retry (first-ok / empty-then-ok via
`fakeAsync` / persistent-empty throws);
`currentWalletNeedsAddressRecovery` matrix; `healCurrentBitboxAddress`
happy + throw
- [x] Bloc: `HomeBloc` diverts to recovery and clears the flag after a
clean load
- [x] Widget: recovery `onCancel` does not throw on a single-entry stack
(+ regression guard)
- [ ] On-device: re-pair an empty-address BitBox wallet → lands on
dashboard; cancel → onboarding
## Automatic Staging PR This PR was automatically created after changes were pushed to staging. **Commits:** 1 new commit(s) ### Checklist - [ ] Review all changes - [ ] Verify CI passes - [ ] Approve and merge to promote into develop Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
## Summary Bumps the `bitbox_flutter` dependency from `v0.0.7` to `v0.0.8`. `v0.0.8` contains the Android pairing-code fix (DFXswiss/bitbox_flutter#26): `initBitBox` now runs off the serial MethodChannel task queue, so the BitBox pairing code (channel hash) appears in the app and on the device **simultaneously**, instead of only after confirming on the device. iOS was already unaffected. ## Test plan - [ ] `flutter pub get` resolves `bitbox_flutter v0.0.8` - [ ] Android BitBox pairing: pairing code shows in-app at the same time as on the device (before on-device confirmation) - [ ] Existing BitBox flows (connect / sign) unaffected
## Automatic Staging PR This PR was automatically created after changes were pushed to staging. **Commits:** 1 new commit(s) ### Checklist - [ ] Review all changes - [ ] Verify CI passes - [ ] Approve and merge to promote into develop Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
fix(home): catch recovery-gate failure + pin error surface in a test
## Summary
Fixes the post-account-merge "Wallet registration not complete" error
(red banner) in the RealUnit app, the CONTRIBUTING-aligned way.
When a new wallet signs up with an email that already belongs to an
existing **DFX** account (account merge) and that account has **no prior
RealUnit registration**, `GET /v1/realunit/registration` returns
`state=NewRegistration`. The email-verification step, however, called
`registerWallet` (`POST /register/wallet`) **unconditionally** — that
endpoint only *adds* a wallet to an **existing** registration, so the
API returns `400 "No RealUnit registration found"`.
## Root cause — runtime-confirmed (PROD App Insights)
- `GET /v1/realunit/registration` → **200** (state=NewRegistration,
userData present from existing KYC)
- `POST /v1/realunit/register/wallet` → **400 `{"message":"No RealUnit
registration found"}`** (×4 retries)
- recovery: the user reached the KYC registration form → `POST
/v1/realunit/register/complete` → **201**
- DB: the merged account had no `RealUnitRegistration` step until
`register/complete` created it. 7-day breadth: 7× this 400 (affects
every merge into a DFX account without a prior RealUnit registration).
## Fix (app-only) — single source of registration routing
The email-verification flow is reduced to its actual job: **confirm the
merge** (detect the JWT account change) and hand back to the KYC flow.
`KycCubit` is now the only place that interprets the registration
`state` and routes it (addWallet → link wallet, NewRegistration → full
registration form, AlreadyRegistered → forward) — per CONTRIBUTING.md
"API as Decision Authority". This removes the duplicated, unconditional
`register/wallet` call.
Dead code removed accordingly: `_completeRegistration`,
`_mergeDetected`, the `RealUnitRegistrationService` dependency, the
`KycEmailVerificationRegistrationFailure` state, and the now-unused i18n
key `registerEmailVerificationRegistrationFailed` (de + en).
## Tests
- email cubit (simplified): same account → Failure (link not visited);
changed account → Success (merge confirmed; no registration here); retry
(Failure → Success).
- `kyc_step_states_test` updated for the removed state.
## Test plan
- [x] `flutter analyze` clean
- [x] `flutter test --exclude-tags golden` — full suite passes (2312)
- [x] Coverage Floor Gate replicated locally — scoped **lines 100.0%**
(floor 100)
- [ ] DEV end-to-end: merge into a DFX account without RealUnit
registration → no red error → lands on the registration form →
register/complete
Supersedes #711 (minimal variant) and the earlier incorrect
MergeProcessing PRs DFXswiss/api#3848 + #709.
## Automatic Staging PR This PR was automatically created after changes were pushed to staging. **Commits:** 1 new commit(s) ### Checklist - [ ] Review all changes - [ ] Verify CI passes - [ ] Approve and merge to promote into develop --------- Co-authored-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Automatic Release PR
This PR was automatically created after changes were pushed to develop.
Commits: 1 new commit(s)
Checklist